feat(sdk): variable declaration edit ops (declare/update/remove)#2047
Conversation
c9bc8fb to
d3ab9db
Compare
d3ab9db to
ed9f222
Compare
9e24243 to
5a63475
Compare
ed9f222 to
52b7e1d
Compare
5a63475 to
6844906
Compare
52b7e1d to
1935108
Compare
1935108 to
3c94b40
Compare
6844906 to
0e3d59c
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
LGTM with one nit. The declaration ops are well-designed — the patch grammar (/variableDeclarations/{id} vs /variables/{id}), the fragment composition guard, and the isRawDeclarationEntry vs isCompositionVariable split all reflect deliberate design choices that are well-documented. Test coverage is thorough, especially the undo round-trips with CSS prop restoration.
Nit (non-blocking): The CSS compat prop write happens via cssCompatChange for declaration ops but via inline logic in the pre-existing handleSetVariableValue. Both produce identical --{id} style patches through getElementStyles/setElementStyles. Not a bug today (the ops target different patch paths), but a future refactor could unify the CSS-prop write path to a single actor. Low priority since the patch grammar prevents conflicts.
The isRawDeclarationEntry guard on the replay path is the right call — gating on isCompositionVariable there would silently break undo for loose declarations. The comment documenting this is appreciated.
— Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 3c94b400. Stack context: SDK foundation (#2046-#2048) — bottom of the 12-PR template-variables stack.
Substantive write surface with a well-thought-out contract. The strict-writer / loose-replay asymmetry called out in the PR body — and re-documented in apply-patches.ts L254-259 — is the right call for undo fidelity; the equivalent bug in another patch grammar would silently no-op inverses. Path grammar keeps /variableDeclarations/{id} and /variables/{id} cleanly separated, and the override-set mapping (varDecl.{id} vs var.{id}) round-trips through pathToKey + keyToPath. Fragment refusal (E_FRAGMENT_COMPOSITION) is exactly the right shape — loud failure beats data loss.
🟠 Hold
packages/parsers/src/compositionVariables.ts:isCompositionVariableL43-50 — the id predicate istypeof v.id === "string"and nothing else. That accepts"", whitespace, HTML metacharacters, ids with/or.or~, and reserved-looking keys likeconstructor. The path grammar happens to survive/and~becausevariableDeclPath/variablePathdon't RFC-6902-escape andparsePathuses a greedy dot regex — so paths round-trip, but the override-set key spacevarDecl.{id}is only unambiguous by convention. A user-authored id.fooorfoo/barcan be represented, but every downstream consumer (Studio autocompletion, PostHog attribution, CSS--{id}writes,--variablesCLI parsing) is going to make assumptions the SDK didn't enforce. The SDK is the last defense before Studio and embedders — recommend a minimal/^[A-Za-z_][A-Za-z0-9_-]*$/(CSS-custom-prop-safe, matches the runtime'scssVariableNameslug convention) surfaced asE_INVALID_ARGSfromcan(). Zero-cost tightening now beats retrofitting after a customer authorsid: "brand.color"and Studio starts silently overwriting other keys.
🟡 Nits / questions
packages/sdk/src/engine/mutate.ts:handleDeclareVariableL972-978 — theisScalar(declaration.default)guard writes--{id}: <string>to the root's inline styles for ANY string-defaulted variable, includingtype: "image". For a scalar image URL that lands as--imgVar: https://cdn.example.com/x.png, which isn't consumable bybackground: var(--imgVar)without aurl(…)wrapper.injectCompositionCssVariablesin the runtime does the same, so this is at least consistent — but if the image variable exists purely so Studio can preview thumbnails, that CSS write is dead weight. Consider gating ondeclaration.type(scalar forstring/number/color/boolean/enum), not on the runtime typeof of the default. Small cleanup; not a correctness bug.isCompositionVariableaccepts{type: "enum", default: "not-in-options", options: [{value: "wide"}]}— the strict parser doesn't cross-check the enum default against the options array.validateVariablescatches it at value-check time but a declaration that will never validate is a weird artifact to accept. Givencan()is the authoring guard, folding the cross-check in there would be a one-line addition.writeVariableDeclaration(variableModel.tsL77-94) silently discards an unparseable prior attribute — documented, no functional issue, but the inverse-patch chain won't restore that prior garbage on undo. The comment justifies it correctly ("invisible to every reader"), just flagging for the reader who traces this later.packages/sdk/src/engine/mutate.ts:handleUpdateVariableDeclarationL1001 usesJSON.stringify(oldDefault) !== JSON.stringify(newDefault)for deep-equal. Object-key order matters for JSON.stringify comparison — a font/image object whose properties were re-ordered would false-positive as "changed" and emit a redundant patch. Low-cost false positive; consider a shallow deep-equal helper if this becomes a churn source.
🟢 What's good
- The BOTH-halves-of-the-schema-in-one-model invariant (
variableModel.tsshared between forward mutate + replay apply-patches) is real, not aspirational. Grep confirms both entry points route through the same three primitives. This is exactly the fix pattern infeedback_be_fe_paired_silent_swallow— one seam, two consumers, one source of truth. - Paired
/variables/{id}value patch onupdateVariableDeclarationwhen the default changes (mutate.ts L999-1010) means the T3 override-set stays coherent regardless of replay order — the failure mode you called out in the PR body is a real one, and the fix is minimal. session.variabledecls.test.tsL192-217 pins the exact patch grammar the override-set contract depends on. That's the test I'd write.- CI: all required checks green; the failing "Perf: matrix.shard" is a template-substitution CI glitch unrelated to this diff.
miguel-heygen
left a comment
There was a problem hiding this comment.
Stamped after Miga's stack review. GitHub state checked: mergeable, no red checks visible; #2046 base conflict still needs resolution separately.
vanceingalls
left a comment
There was a problem hiding this comment.
R1 — hyperframes #2047 at 3c94b400
🟢 LGTM on the write mechanism, concurring with Miga; concur with Rames-D's 🟠 Hold on the loose id predicate.
Verified independently at head SHA:
- Write→read round-trip.
writeVariableDeclarationaccepts both typedCompositionVariableand raw entries viaisRawDeclarationEntryguard — critical forapply-patches.ts:99-105REPLAY fidelity (loose entries restored verbatim for undo). Intentional predicate looseness documented at the write site. - Fragment guard.
E_FRAGMENT_COMPOSITIONrejection atmutate.ts:930—serialize()strips the synthetic wrapper on fragments, so a declaration written there would silently vanish. Locked in bysession.variabledecls.test.ts:601-611. (Semantics narrow further in #2081 — see my note there.) - Patch-grammar collision-free.
/variableDeclarations/{id}↔varDecl.{id}distinct from/variables/{id}(value channel).keyToPath/pathToKeyround-trip at:696-705. Paired/variablespatch emitted whendefaultchanges so T3 override-set stays coherent regardless of replay order (:254-265). - CSS compat channel.
cssCompatChangemaintains--{id}custom-props on declare/update/remove for scalar defaults; object-valued font/image correctly clear. Same helper wired through all three mutate handlers, so a future edit that touches one path can't drift. - Idempotency + type conflict.
declareVariableno-ops on duplicate id (E_DUPLICATE_VARIABLE);updateVariableDeclarationenforces id immutability (remove + declare to rename). - 218 test lines covering undo/redo + CSS-prop restoration + serialize round-trip.
- CI green.
Concurring with Miga's LGTM.
Concurring with Rames-D's 🟠 Hold:
isCompositionVariableid predicate istypeof v.id === "string"and nothing else. Rames-D's read is right:"", whitespace,foo.bar,brand/color,constructor-like reserved names all pass, and while the path grammar happens to survive becausevariableDeclPath/variablePathdon't RFC-6902-escape,varDecl.{id}unambiguity is by convention not by construction. Downstream consumers (Studio autocompletion,--variablesCLI parsing, CSS--{id}writes, PostHog attribution) will each independently assume something the SDK didn't enforce. Concur on the tightening:/^[A-Za-z_][A-Za-z0-9_-]*$/(CSS-custom-prop-safe, matches the runtime's slug convention) surfaced asE_INVALID_ARGSfromcan(). Retrofit cost is much higher after a customer authorsid: "brand.color"and Studio silently overwrites other keys.
No unique blockers beyond Rames-D's coverage.
R1 by Via
3c94b40 to
d55c96d
Compare
0e3d59c to
9b1d9e8
Compare
d55c96d to
b0a0727
Compare
|
Addressed R1 🟠 Hold (loose id predicate). |
b0a0727 to
d3df94c
Compare

What
Second PR of the template-variables stack: the SDK write surface for the variable schema.
Compositionwrappers:declareVariable(declaration),updateVariableDeclaration(id, declaration)(wholesale replace, id immutable — rename = remove + declare),removeVariableDeclaration(id)(last removal drops the whole attribute)/variableDeclarations/{id}↔ override keyvarDecl.{id}— distinct from/variables/{id}, which stays the default-value path; apply-patches replays declaration patches for T3 host undocan()validation:E_DUPLICATE_VARIABLE,E_VARIABLE_NOT_FOUND,E_INVALID_ARGS, andE_FRAGMENT_COMPOSITIONisCompositionVariableso writers validate with the exact predicate readers filter by--{id}CSS compat custom prop consistently (declare included), andupdateVariableDeclarationemits a paired/variables/{id}patch when the default changes so the T3 override-set stays coherent regardless of replay order (review fixes absorbed into this PR)Why
setVariableValuecould edit an existing declaration's default but nothing could create, reshape, or delete declarations — the missing half of the schema-authoring surface Studio's Variables panel (and any embedder) needs. Everything flows through the established dispatch → patch → override-set pipeline so history, undo, and embedded hosts work for free.How
Notable contract decision: fragment compositions refuse declaration ops (
E_FRAGMENT_COMPOSITION). A fragment source has no<html>of its own — a declaration written to the synthetic wrapper is silently dropped byserialize(), which a test proved before the guard existed. Refusing loudly beats losing data quietly.The patch replay path (
apply-patches.ts) is deliberately not strict: inverse patches capture raw entries verbatim (including loose hand-authored declarations the strict parser drops), so undo must restore them verbatim — gating replay onisCompositionVariablemade undo of a remove/update on a loose entry silently no-op (caught in review, fixed here).Test plan
session.variabledecls.test.ts— dispatch semantics,can()error paths, CSS compat sync, patch grammar round-trips, undo/redo (including CSS prop restoration), fragment refusal🤖 Generated with Claude Code